Amazon Cognito Hosted UIで、署名付きURLを使用し認証ユーザーごとにプレフィックス分けてS3バケットにアップロードする

Amazon Cognito Hosted UIで、署名付きURLを使用し認証ユーザーごとにプレフィックス分けてS3バケットにアップロードする

Clock Icon2024.09.03

はじめに

本記事では、Amazon Cognito Hosted UIを利用して、単一のS3バケットに対して署名付きURLを用いて、認証済みユーザーごとにプレフィックスを分けたアップロード方法を解説します。

この手法により、各ユーザーのファイルを効率的かつセキュアに管理することが可能になります。

以前、クライアントからEC2インスタンス経由でS3バケットにファイルをアップロードする方法を紹介しました。しかし、この方式では、大容量ファイルのアップロード時にEC2インスタンスに過度な負荷がかかるという課題がありました。

https://dev.classmethod.jp/articles/amazon-cognito-hosted-ui-s3-user-specific-upload/

今回は、署名付きURLでクライアントから直接S3にファイルをアップロードする方式で試してみます。

S3の署名付きURLは、S3バケットへのアクセスを一時的かつセキュアに行うことを可能にする機能です。アップロードに関する主な特徴は以下の通りです

  1. 作成者の権限による一時的アクセス

    • 署名付きURLは、それを生成したユーザーの認証情報と権限を使用します。つまり、URLを作成したユーザーの許可範囲内で、他のユーザーに一時的なアクセスを提供できます。
  2. セキュアなアップロード

    • パブリックアクセスが制限されているバケットに対しても、特定のファイルをアップロードできます。
  3. クライアントサイドからの直接アップロード

    • 署名付きURLを使用することで、クライアントから直接S3バケットにファイルをアップロードできます。これにより、サーバーの負荷を軽減し、大容量ファイルの効率的な転送が可能になります。
  4. AWS認証情報不要:

    • URLを受け取ったユーザーは、自身のAWS認証情報やアクセス許可を持っていなくても、指定されたオブジェクトをアップロードできます。

本システムの構成は以下の図の通りです。

cm-hirai-screenshot 2024-09-02 13.55.30

ALBがユーザーの認証に成功すると、EC2インスタンスへのリクエスト転送時に追加のHTTPヘッダーを含めます。このヘッダーにはx-amzn-oidc-identityが含まれており、これはユーザーの一意の識別子(ユーザーID)であり、Cognitoのsubクレームに相当します。

EC2インスタンスのアプリケーションコードでは、このx-amzn-oidc-identityの値を利用して、S3バケット内でユーザーごとに固有のプレフィックス(ユーザーID)を生成し、そのプレフィックス配下にファイルをアップロードします。

前提条件

本手順を実施するには、以下の準備が必要です

  • EC2インスタンスの起動
    • OSは、Amazon Linux 2023を使用
    • セキュリティグループのインバウンドルールで、ALBのセキュリティグループからポート3000への接続を許可
    • AWS Systems Manager セッションマネージャーで接続可能な設定
    • IAMロールにAmazonS3FullAccessAmazonSSMManagedInstanceCoreポリシーを適用
      • 署名付きURL作成のため、EC2インスタンスにs3:PutObject権限が必要
  • Cognitoユーザープールの作成とALBの設定
  • ファイルアップロード用のS3バケットの作成

S3バケットのCORS設定

アプリケーションから署名付きURLを使用してS3バケットに直接アップロードするためには、S3バケット側でアプリケーションのドメインからのアクセスを許可する必要があります。
これを実現するのがCORS(Cross-Origin Resource Sharing)設定です。

S3バケットのCORS設定は以下の通りです。ここで指定するドメインは、Route53などでALBに向けているアプリケーションのドメインを使用してください。

[
    {
        "AllowedHeaders": [
            "Content-Type"
        ],
        "AllowedMethods": [
            "PUT"
        ],
        "AllowedOrigins": [
            "https://ドメイン"
        ],
        "ExposeHeaders": []
    }
]

cm-hirai-screenshot 2024-09-02 14.14.31

EC2インスタンスに接続

セッションマネージャーを使用してEC2インスタンスに接続した後、以下のコマンドを実行してアプリケーションのセットアップスクリプトを作成します。

sh-5.2$ cd /home/ssm-user

sh-5.2$ vim setup_app.sh

作成するスクリプト内容は以下の通りです。

このスクリプトは、必要なソフトウェアのインストール、アプリケーションファイルの作成、依存関係のインストール、そしてアプリケーションを起動します。

リージョンとS3バケット名は、ご自身の環境に合わせて変更してください。なお、署名付きURLの有効時間(expirationTime)は、デフォルトで60秒に設定しています。必要に応じてこの値を調整してください。

setup_app.sh
#!/bin/bash

# 必要なパッケージのインストール
sudo yum update -y
sudo yum install -y nodejs npm

# アプリケーションディレクトリの作成
mkdir -p ~/s3-presigned-url-app
cd ~/s3-presigned-url-app

# package.jsonの作成
npm init -y

# 必要な依存関係のインストール
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner express

# アプリケーションコードの作成
cat << 'EOF' > app.js
const express = require('express');
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const path = require('path');

const app = express();
const port = 3000;
const region = "ap-northeast-1";
const bucket = "cm-hirai-cognito";
const expirationTime = 60;

const s3Client = new S3Client({ region });

app.use(express.static('public'));

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

app.get('/get-signed-url', async (req, res) => {
  try {
    const userId = req.headers['x-amzn-oidc-identity'];
    if (!userId) {
      return res.status(401).json({ error: "Unauthorized" });
    }

    const { filename } = req.query;
    if (!filename) {
      return res.status(400).json({ error: "Filename is required" });
    }

    const key = `${userId}/${filename}`;

    const command = new PutObjectCommand({ Bucket: bucket, Key: key });
    const signedUrl = await getSignedUrl(s3Client, command, { expiresIn: expirationTime });
    res.json({ signedUrl, key });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Error generating signed URL" });
  }
});

app.listen(port, '0.0.0.0', () => {
  console.log(`Server running on port ${port}`);
});
EOF

# HTMLファイルの作成
mkdir public
cat << 'EOF' > public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>S3 Presigned URL File Uploader</title>
</head>
<body>
    <h1>S3 Presigned URL File Uploader</h1>
    <input type="file" id="fileInput">
    <button onclick="uploadFile()">Upload File</button>
    <div id="status"></div>

    <script src="/script.js"></script>
</body>
</html>
EOF

# クライアントサイド(ブラウザ)で実行されるJavaScriptコード
cat << 'EOF' > public/script.js
async function uploadFile() {
    const fileInput = document.getElementById('fileInput');
    const statusDiv = document.getElementById('status');
    const file = fileInput.files[0];
    if (!file) {
        statusDiv.textContent = 'Please select a file first.';
        return;
    }

    try {
        const response = await fetch(`/get-signed-url?filename=${encodeURIComponent(file.name)}`);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const { signedUrl, key } = await response.json();

        const uploadResponse = await fetch(signedUrl, {
            method: 'PUT',
            body: file,
            headers: {
                'Content-Type': file.type
            }
        });

        if (!uploadResponse.ok) {
            throw new Error(`Upload failed! status: ${uploadResponse.status}`);
        }

        statusDiv.textContent = `File uploaded successfully. S3 key: ${key}`;
    } catch (error) {
        statusDiv.textContent = `Error: ${error.message}`;
    }
}
EOF

# アプリケーションの実行
node app.js

作成したスクリプトに実行権限を付与し、実行します。

sh-5.2$ chmod +x setup_app.sh

sh-5.2$ ./setup_app.sh

Last metadata expiration check: 0:02:25 ago on Mon Sep  2 04:58:37 2024.
Dependencies resolved.
Nothing to do.
Complete!
Last metadata expiration check: 0:02:26 ago on Mon Sep  2 04:58:37 2024.
Dependencies resolved.

~省略~
added 169 packages, and audited 170 packages in 31s

14 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
Server running on port 3000

これでアプリケーションが正常に起動しました。

もしEC2インスタンスを再起動した場合、セッションマネージャーで接続後、以下のコマンドでアプリケーションを再度起動できます。

sh-5.2$ node /home/ssm-user/s3-presigned-url-app/app.js

Server running on port 3000

ユーザーIDと署名付きURL生成

ユーザーID(x-amzn-oidc-identity)の値と署名付きURLの作成は、app.jsの以下の箇所です。

app.js
// userIdを取得
    const userId = req.headers['x-amzn-oidc-identity'];

~中略~

// ファイル名を取得
const { filename } = req.query;

~中略~

// S3のキーを作成
    const key = `${userId}/${filename}`;
// 署名付きURLを作成
    const command = new PutObjectCommand({ Bucket: bucket, Key: key });
    const signedUrl = await getSignedUrl(s3Client, command, { expiresIn: expirationTime });

署名付きURLを使用したS3へのファイルアップロード

アップロードは、クライアントサイドのJavaScriptコード(public/script.js)で行われます。このコードの流れは以下の通りです。

  1. ユーザーが選択したファイルを取得します。
  2. アプリケーションのバックエンド(app.js)に署名付きURLをリクエストします。
  3. アプリケーションのバックエンドから署名付きURLを受け取ります。
  4. 受け取った署名付きURLを使用して、fetch APIでファイルを直接S3にアップロードします。

具体的なアップロード処理は、以下の部分で行われています

const uploadResponse = await fetch(signedUrl, {
    method: 'PUT',
    body: file,
    headers: {
        'Content-Type': file.type
    }
});

fetch APIで、署名付きURLに対してPUTリクエストを送信し、ファイルのコンテンツをリクエストボディとして含めています。これにより、ファイルが直接S3バケットにアップロードされます。

アップロードしてみる

アプリケーションを起動した後、ALBのターゲットグループでEC2インスタンスのヘルスチェックが成功していることを確認します。

cm-hirai-screenshot 2024-08-27 11.31.21

ブラウザでアプリケーションのURLにアクセスします。Amazon Cognitoでサインインすると、以下のような画面が表示されます。

[ファイルを選択]ボタンをクリックして画像ファイルを選択し、[Upload File]ボタンをクリックしてアップロードを開始します。

cm-hirai-screenshot 2024-09-02 14.18.52
アップロードが成功すると、以下のような成功メッセージが表示されます。この例では、プレフィックスが8794ba88-9071-707b-e40b-d3a337345949であることが確認できます。

cm-hirai-screenshot 2024-09-02 14.19.12
AWSマネジメントコンソールでも確認できます。
cm-hirai-screenshot 2024-09-02 14.19.38

この8794ba88-9071-707b-e40b-d3a337345949は、Amazon Cognitoで認証されたユーザーの一意のIDです。このIDは、AWSマネジメントコンソールのCognitoユーザープール内のユーザー詳細ページで確認することができます。

cm-hirai-screenshot 2024-09-02 14.20.52

参考

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-id-token.html

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/listener-authenticate-users.html#user-claims-encoding

https://dev.classmethod.jp/articles/http-headers-added-by-alb-and-cognito-are-explained/

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/example_s3_Scenario_PresignedUrl_section.html

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-presigned-url.html

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.